Chapter 7 函数装饰器和闭包

函数装饰器和闭包

函数装饰器 用于在源码中“标记”函数,以某种方式增强函数的行为。想掌握装饰器,必需先理解 闭包

闭包 除了在 装饰器 中有用,还是 回调式异步编程和函数式编程风格 的基础。

准备知识:

7.1 装饰器基础知识

装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。

装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。

示例 7-1:

def deco(func):
	def inner():
		print("running inner()")
	return inner

@deco
def target():
	print("running target()")

print(target())  # 1 输出:running inner() 
print(target)    # 2 输出:<function deco.<locals>.inner at 0xxxxxx>

可以从# 1 的输出看出,调用 target() 时运行的是 runner()# 2 的输出发现是 target 现在是 inner 的引用。也就是说 装饰器能把被装饰的函数替换成其他函数

装饰器第一特性是: 能把被装饰的函数替换成其他函数。

7.2 Python 何时执行装饰器

装饰器第二特性是: 装饰器在加载模块时立即执行。这通常是在导入时(即 Python 加载模块时)。如示例 7-2 中的 registration.py 模块所示。

示例 7-2: registration.py 模块

# registration.py 
registry = []

def register(func):
	print("running register (%s)" % func)
	registry.append(func)
	return func


@register
def f1():
	print("running f1()")


@register
def f2():
	print("running f2()")


def f3():
	print("running f3()")


def main():
	print("running main()")
	print("registry -> ", registry)
	f1()
	f2()
	f3()


if __name__ == "__main__":
	main()

运行后得到如下结果:

running register(<function f1 at 0xxxxxxxx1>)
running register(<function f2 at 0xxxxxxxx2>)
running main()
registry -> [<function f1 at 0xxxxxxxx1>, <function f2 at 0xxxxxxxx2>]
running f1()
running f2()
running f3()

示例 7-2 演示函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 python 程序员所说的 导入时运行时 之间的区别。

总结:

对比 示例 7-1 的装饰器(deco)和 示例 7-2 中的装饰器(register)定义时的不同之处。

定义 deco 装饰器时,其返回值是其函数体内定义的 inner ,而定义 register 装饰器时,其返回值是传入函数本身。这是导致调用被装饰函数(target() f1())时,输出结果不一致的根本,即调用 target() 时返回值是 inner 函数中的输出语句,而调用 f1() 时返回值却是 f1() 函数本身的输出语句。大多数装饰器都是像 deco 装饰器一样返回值是内部定义的函数。

装饰器在实际代码中常用方式与 示例 7-2 有两个不同的地方。

7-3 使用装饰器改进 “策略” 模式

使用装饰器改进 Chapter 6 中的策略模式。

示例 7-3: promots 列表中的值使用 promotion 装饰器填充

promos = []  
  
  
def promotion(promo_func):  
    promos.append(promo_func)  
    return promo_func  
  
  
@promotion  
def fidelity(order):  
    return order.total() * .05 if order.customer.fidelity >= 1000 else 0  
  
  
@promotion  
def bulk_item(order):  
    discount = 0  
    for item in order.cart:  
        if item.quantity >= 20:  
            discount += item.total() * .1  
    return discount  
  
  
@promotion  
def large_order(order):  
    distinct_items = {item.product for item in order.cart}  
    if len(distinct_items) >= 10:  
        return order.total() * .07  
    return 0  
  
  
def best_promo(order):  
    return max(promo(order) for promo in promos)

这个方案有以下几个 优点

7.4 变量作用域规则

正如 7.2 结尾的总结所说,多数装饰器会修改被装饰的函数。通常,会定义一个内部函数,然后将其返回,替换被装饰器的函数。使用用内部函数的代码几乎都要靠闭包才能正确运作。为了解闭包,需要先了解 Python 中的变量作用域。

需要先了解的是 Python 的设计选择:Python 不要求声明变量,但是==在函数定义体内赋值的变量默认是局部变量==。

示例 7-4:\

# 在 IDLE Shell 中编写、运行
def f1(a):
	print(a)
	print(b)

f1(3)
# 输出结果:
# 3
# Traceback (most recent call last):
#   File "<pyshell#4>", line 1, in <module>
#     f1(3)
#   File "<pyshell#3>", line 3, in f1
#     print(b)
# NameError: name 'b' is not defined

与预期结果一致:变量 b 没有定义。如果先给变量 b 赋值,然后调用 f1 则不会出错,此时变量 b 为全局变量。

# 继续
b = 6
f1(3)
# 输出结果
# 3
# 6

示例 7-5:

# 在 IDLE Shell 中编写、运行
b = 6  #1
def f2(a):
	print(a)
	print(b)  #2
	b = 9  #3

f2(3)
# 输出结果
# 3
# Traceback (most recent call last):
# File "<pyshell#12>", line 1, in <module>
#   f2(3)
# File "<pyshell#11>", line 3, in f2
#   print(b)
# UnboundLocalError: local variable 'b' referenced before assignment

在函数定义体内的变量默认是局部变量,也就是说,#1 处的 b 是全局变量,而 #2#3 处的变量是局部变量。函数 f2 定义体内 #2 处之前,局部变量 b 没有赋值,#2 处直接输出局部变量 b 则报错(UnboundLoaclError)。

如果在函数中赋值时想让解释器把 b 当成全局变量,要使用 global 声明:

示例 7-5-1:

b = 6
def f3(a):
	global b
	print(a)
	print(b)
	b = 9

f3(3)
# 输出结果
# 3
# 6
b
# 输出结果
# 9
f3(3)
# 输出结果
# 3
# 9
b 
# 输出结果
# 9
b = 10
b
# 输出结果
# 10
f3(3)
# 输出结果
# 3
# 9
b
# 输出结果
# 9

f3 的作用是把全局变量 b 的值初始化为 9

如果 示例 7-5#2 处语句与 #3 处语句调换一下位置,则不会报错,但此时与 示例 7-5-1 的意义不同

示例 7-5#2 处语句与 #3 处语句调换位置后,全局变量 b 的值不会被初始化,即全局变量 b 的值修改后,调用 f2 函数,全局变量的值不会被初始化为 9;而 示例 7-5-1 是,不论全局变量 b 的值被修改成哪个值,调用 f3 函数后,全局变量的值都会被初始化为 9

7.5 闭包

闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。

在闭包的概念中,函数是不是匿名的没有关系,关键是 它能访问定义体之外定义的非全局变量

通过示例理解闭包。

示例: 有个名为 avg 的函数,它的作用是计算不断增加的系列值的均值。如:初始avg(10) 结果是 10,即 10/1;则 avg(11) 结果是 10.5,即 (10+11)/2avg(12) 结果是 11,即 (10+11+12)/3

示例 7-8: 计算移动平均值的类

class Averager():
	def __init__(self):
		self.series = []

	def __call__(self, new_value):
		self.series.append(new_value)
		total = sum(self.series)
		return total/len(self.series)

运行后,可以得到预期结果。

示例 7-9: 计算移动平均值的高阶函数

def make_averager():
	series = []
	def averager(new_value):
		series.append(new_value)
		total = sum(series)
		return total/len(series)
	return averager

运行后,也可以得到预期结果。

示例 7-8 中,Averager 类的实列 avg 用其属性 self.series 存储历史值;
示例 7-9 中,avg 函数在哪里寻找 series?需要注意的是,seriesmake_averager 函数的局部变量(在其函数定义体中初始化了 series,即 series = [])。但是,前一节内容说过,在函数定义体内赋值的变量,默认是局部变量,调用 avg(10) 时,make_averager 函数已经返回了,那么其自身(make_averager)函数体内定义的局部变量已经收回,再调用 avg(11) 时,series 又被初始化为空值,结果应为 11,即 (11/1),也就是说 示例 7-9 中的 series 不能存储历史值。但是的但是,示例 7-9 不但可以正常运行,还能得到预期结果,为什么?

实际上,示例 7-9 中的 averager 函数中,series 不是局部变量,而是自由变量(free variable,指未在本地作用域中绑定的变量),图 7-1。

7-1.png|图 7-1: averager 的闭包延伸到那个函数的作用域之外,包含自由变量 series 的绑定

对象(函数也是对象)的__code__ 属性中保存局部变量和自由变量。

avg.__code__.co_varnames
# 输出:('new_value', 'total')
make_averager().__code__.co_varnames
# 输出:('new_value', 'total')

avg.__code__.co_freevars
# 输出:('series',)
make_averager().__code__.co_freevars
# 输出:('series',)

series 的绑定在返回的 avg 函数的 __closure__ 属性中,即函数的 __closure__属性中保存着所有自由变量。avg.__closure__ 中的各个元素对应 avg.__code__.freevars 中的一个名称,avg 函数中只有一个自由变量,所以 avg.__closure__[0]avg.__code__.freevars[0] 的值都是 series。这些元素是 cell 对象,有个 cell_contents 属性,保存着真正的值。

avg.__code__.freevars[0]
# 输出:series
avg.__closure__[0]
# 输出:series
avg.__closure__[0].cell_contents
# 输出:[10, 11, 12]

总结: 闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。

注意: 只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

7.6 nonlocal

示例 7-9 中实现的 make_averager 函数因为把所有值存储在历史数列中,然后每次调用时使用 sum 求和,导致效率不高。更好的实现方式是,只存储目前的总值和元素个数,然后用这两个数计算平均值。

示例 7-13: 总值和元素个数实现移动增值。不能运行,会报错。

def make_averager():
	count = 0
	total = 0
	def averager(new_value):
		count += 1
		total += new_value
		return total/count
	return

运行后输出结果:

avg = make_averager()
avg(10)
# 输出结果,报错
# Traceback (most recent call last):
# ...
# UnboundLocalError: local variable 'count' referenced before assignment

报错的原因:count 是数字或任何不可变类型时, count += 1 语句的作用与 count = count + 1 一样。因此,在 averager 的定义体中为 count 赋了值,这会把 count 变成局部变量。total 也是这样。

示例 7-9 为什么没有遇到这个问题?是因为在 averager 的定义体中没有对 series 赋值,只是调用了 series.append 方法,然后把新的 series 传给 sumlen。也就是说,我们利用了列表是可变对象这一事实。

但是对==数字、字符串、元组等不可变类型==来说,只能读取,不能更新。如果尝试重新绑定(也就是赋值),例如 count += count ,这会隐式创建局部变量 count。这样 count 就不是自由变量了,而是局部变量,因此不会保存在闭包中。

为了解决这个问题, Python3 引入了 nonlocal 关键字,用来把变量标记为自由变量即使函数中为变量重新赋值,也还是自由变量,不会变为局部变量

示例 7-14: 使用 nonlocal 修正 示例 7-13

def make_averager():
	count = 0
	total = 0
	def averager(new_value):
		nonlocal count, total
		count += 1
		total += new_value
		return total / count
	return averager

正常运行,并得到预期结果。

7.7 实现一个简单的装饰器

示例 7-15 定义了一个装饰器,在每次调用被装饰函数时计时,然后把运行时间、传入的参数和调用的结果打印出来。

示例 7-15: 一个简单的装饰器,输出函数的运行时间

import time

def clock(func):
	def clocked(*args):
		t0 = time.pref_counter()
		result = func(*args)
		elapsed = time.perf_counter() - t0
		name = func.__name__
		arg_str = ", ".join(repr(arg) for arg in args)
		print("[%0.8f] %s(%s) -> %r" % (elapsed, name, arg_str, result))
		return result
	return clocked

示例 7-16: 使用 clock 装饰器

from clockdeco import *  
  
  
@clock  
def snooze(seconds):  
    time.sleep(seconds)  
  
  
@clock  
def factorial(n):  
    return 1 if n < 2 else n*factorial(n-1)  
  
  
if __name__ == "__main__":  
    print("*" * 40, "Calling snooze(.123)")  
    snooze(.123)  
    print("*" * 40, "Calling factorial(6)")  
    print("6! = ", factorial(6))

运行后,输出结果如下:

**************************************** Calling snooze(.123)
[0.12637420s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000030s] factorial(1) -> 1
[0.00000700s] factorial(2) -> 2
[0.00001130s] factorial(3) -> 6
[0.00001550s] factorial(4) -> 24
[0.00002060s] factorial(5) -> 120
[0.00002650s] factorial(6) -> 720
6! =  720

工作原理

如下代码:

@clock
def factorial(n):
	return 1 if n < 2 else n*factorial(n-1)

等价于:

def facotrial(n):
	return 1 if n < 2 else n*factorial(n-1)

factorial = clock(factorial)

因此,factorial 会作为 func 参数传给 clock。然后,clock 函数会返回 clocked 函数,Python 解释器在背后会把 clocked 赋值给 factorial。所以,经过 @clock 装饰的 factorial 函数,保存的是 clocked 函数的引用。自此之后,每次调用 factorial(n),执行的都是 clocked(n)clocked 大致做了下面几件事。

  1. 记录初始时间 t0
  2. 调用原来的 factorial 函数,保存结果。
  3. 计算经过的时间。
  4. 格式化收集的数据,然后打印出来。
  5. 返回第 2 步保存的结果。

这是装饰器的典型行为:把被装饰的函数替换成新函数,二者接受相同的参数,而且(通常)返回被装饰的函数本该返回的值,同时还会做些额外的操作。

示例 7-15 中实现的 clock 装饰器有两个缺点:

  1. 不支持关键字参数
  2. 遮盖了被装饰函数的 __name____doc__ 属性

示例 7-17 使用 functools.wraps 装饰器把相关的属性从 func 复制到 clocked 中。另外,这个新版还能正确处理关键字参数。

示例 7-17: 改进后的 clock 装饰器

# clockdeco2.py
import time 
import functools

def clock(func):
	@functools.wraps(func)
	def clocked(*args, **kwargs):
		t0 = time.time()
		result = func(*args, **kwargs)
		elapsed = time.time() - t0
		name = func.__name__
		arg_lst = []
		if args:
			arg_lst.append(", ".join(repr(arg) for arg in args))
		if kwargs:
			pairs = ["%s=%r" % (k, w) for k, w in sorted(kwargs.items())]
			arg_lst.append(", ".join(pairs))
		arg_str = ", ".join(arg_lst)
		print("[%0.8fs] %s(%s) -> %r" % (elapsed, name, arg_str, result))
		return result
	return clocked

@functools.wraps 装饰器的作用是协助构建行为良好的装饰器。

7.8 标准库中的装饰器

标准库中最值得关注的两个装饰器是 lru_cachePython 3.4 中新增的 singledispatch,这两个装饰器都在 functools 模块中定义。

7.8.1 使用 functools.lru_cache 做备忘

functools.lru_cache 是非常实用的装饰器,它实现了备忘(memoization)功能,把耗时的函数结果保存起来,避免传入相同的参数时重复计算。LURLeast Recently Used 的缩写,表明缓存不会无限制增长,一段时间不用的缓存条目会被扔掉。

生成第 n斐波纳契数 这种慢速递归函数适合使用 lru_cache,如示例 7-18 所示。

示例 7-18: 生成第 n斐波纳契数

斐波纳契数F(0)=0F(1)=1,F(n)=F(n1)+F(n2)n2nN)

from clockedco import clock

@clock
def fibonacci(n):
	if n < 2:
		return n
	return fibonicci(n-2) + fibonacci(n-1)

if __name__ == "__main__":
	print(fibonacci(6))

输出结果:

[0.00000040s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00002850s] fibonacci(2) -> 1
[0.00000020s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00000840s] fibonacci(2) -> 1
[0.00001630s] fibonacci(3) -> 2
[0.00005370s] fibonacci(4) -> 3
[0.00000020s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00000770s] fibonacci(2) -> 1
[0.00001540s] fibonacci(3) -> 2
[0.00000020s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00000770s] fibonacci(2) -> 1
[0.00000010s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00000800s] fibonacci(2) -> 1
[0.00001540s] fibonacci(3) -> 2
[0.00003090s] fibonacci(4) -> 3
[0.00005390s] fibonacci(5) -> 5
[0.00011560s] fibonacci(6) -> 8
8

很明显,fibonacci(1) 调了 8 次,fibonacci(2) 调用了 5 次...,浪费时间。使用 lru_cache 性能可以显著改善。

示例 7-19: 使用 lru_cache 修改 示例 7-19

import functools

from clockdeco import *

@functools.lru_cache()
@clock
def fibonacci(n):
	if n < 2:
		return n
	return fibonacci(n-2) + fibonacci(n-1)

if __name__ == "__main__":
	print(fibonacci(6))

输出结果:

[0.00000040s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00002500s] fibonacci(2) -> 1
[0.00000050s] fibonacci(3) -> 2
[0.00003400s] fibonacci(4) -> 3
[0.00000030s] fibonacci(5) -> 5
[0.00004280s] fibonacci(6) -> 8
8

需要注意的是: 使用 @functools.lru_cache() 时,后的一对括号不能丢掉,这是因为这个装饰器可以接收参数。

lru_cache 可以使用两个可选参数:maxsizetyped

functools.lru_cache(maxsize=128, typed=False)maxsize 指定存储多少个调用结果,为了得到最佳性能,maxsize 应该设为 2 的幂次方;typed 指是否把不同参数类型的结果分开保存,即把通常认为相等的浮点数和整数参数(如 1 和 1.0)区分开。

另外,被 lru_cache 装饰的函数,它的所有参数都必须是可散列的

7.8.2 单分派泛函数

7.9 叠放装饰器

7.10 参数化装饰器